Write a Modal Test with Cypress
On this page
We’ve had some practice with the Cypress API through writing page-level tests and simple interactions at the component level.
It’s time to take it up a notch and start writing tests that mimic more complex interactions than just a single keypress or click.
Cypress shines when it comes to writing integration tests that more closely imitate how an actual user would interact with a site. It allows for testing navigation and other scenarios to identify issues that tools alone might not be able to catch.
There are plenty of opportunities to write these tests for the CampSpots Passes page.
The Passes page features two pricing cards with “Join Now” buttons that open a modal with a payment form.
In this lesson, we’ll be adding some tests to PassesPage.test.js inside of the exercise3-cypress-integration directory.
To prevent a test from running, add an “x” to the it in front of the test block: xit(’should have no accessibility violations’)
🛠 Challenge: Write a Test for Payment Dialog Focus Management
When focus management is done correctly, a user who opens a modal or other interactive component will be taken back to the element they started from when closing out of it.
See the focus management lessons in the Coding Accessible Interactions & Mechanics workshop for more.
When the user clicks a “Join Now” button on the pricing page a modal opens. Closing out of the modal should put focus back on the button that opened it.
Your challenge is to write a test that ensures that the payment dialog is accessible.
Your test should interact with a button to open the modal, then ensure that it has focus.
Cypress should then be able to tab to the close button, and when the dialog is closed the button that opened it should have focus.
It’s okay to write a failing test— the important thing is that you capture the steps for the focus management behavior.
Refer back to previous Cypress & Cypress Component Testing exercises, or look in the answer directory if you get stuck.
🛠 Solution Part 1: Writing a Focus Management Test for the Dialog
There are multiple ways to write this test— this is just my approach!
Inside of PassesPage.test.js I’ll add a new it() block with a description of “should have an accessible payment dialog” along with an arrow function:
it('should have an accessible payment dialog', () => {
})Inside the test block I’ll use cy.get() along with the id selector for the Basic “Join Now” button then chain on .focus() and .click() to open the modal.
With the dialog open, I’ll use cy.focused() to check that the focused element has an ARIA role of dialog.
Next, I’ll use cy.realPress() to simulate pressing the Tab key which should give focus to the close button. I’ll assert that the focused element should have an aria-label of “Close Dialog”.
Instead of hitting enter with focus on the close button, I’ll change it up and send an Escape key event. This should close the dialog from wherever the focus is inside of it.
Finally, to check if focus is back at the Basic “Join Now” button, I’ll use cy.focused() and assert the element has the id.
Here’s what my test looks like all together:
it('should have an accessible payment dialog', () => {
cy.get('#btn-join-basic').focus().click()
// focus is sent into dialog
cy.focused().should('have.attr', 'role', 'dialog')
cy.realPress(["Tab"])
cy.focused().should('have.attr', 'aria-label', 'Close Dialog')
cy.realPress(["Escape"])
cy.focused().should('have.attr', 'id', 'btn-join-basic')
})Video Transcript
So for this page, let's write some actual functional tests for this page, cuz there's quite a few things on here that would be great candidates for some functional testing, like the modal opening, sending focus into the modal, like those layers.
Those are really good candidates for page level accessibility concerns. Focus management is a big one. So in our passes page file, I am going to write a new test that says it should have an accessible payment dialogue. And we're gonna write some, write a test for this. So in our test, I could say cy dot get, and we can use any CSS selector we like or data attribute even.
I could say button join basic. So we have two buttons, the gray and the yellow side by side. And those both open. They just sort of change out what content gets shown in the dialogue. So we probably wanna test both of those to have full coverage, but we'll start with one. So with this button, I'm gonna make sure that it's accessible from the keyboard by doing this trick of sending a focus event to it.
And then I will hit click on it. I could also try the, the Cypress real events. If I wanted to send a keyboard key press to it. See if that works. I'm gonna focus more on like the, the modal dialogue layer part at this moment. So when I click this button for an accessible dialogue, I wanna make sure that focus is sent into that new layer.
So I should be able to check if side up focused. That's an API that will return the currently focused element. That should be something that we can test against. So I'm gonna say, should have dot adder for has attribute let's check that it's sent focus to something with a role of dialogue. So did focus get sent into the dialogue?
Good question. So, so focus should be sent into the dialogue. So that's step number one. And at that point, you know, I can keep navigating through it like a user would. So this is where I'm gonna call side real press, that library that we're importing as a plugin, that sort of backfills the event support that Cypress for some reason doesn't have.
the API for calling events here, like tab key is to put within the quotes of real press square bracket, quote, tab as the command, and then close closing square bracket. So that's how you fire a tab key press in Cypress using Cypress real events. So if I have sent focus somewhere, then I should be able to assert what's the next thing in the tab order.
So I can kind of do this keyboard navigation testing to make sure that my focus is being moved around to all the right spots in our web application. So at this point, after I've focused in the dialogue and I've hit tab, once I expect that side up focused should, should be the next item in the tab order which is an element with an ARIA label of closed dialogue.
So the first thing in the tab order within the dialogue is a closed button. It's an icon button, so it should have an ARIA label of closed dialogue. I could also check that it's like an element with a data data attribute like a test ID or that it has a role of button, you know, I can assert all kinds of things like we wanna think about what's the most useful and robust test that we could write and closed dialogue is kind of on the fence of like, it's helpful because we want the close button to be the next thing in the tab order.
I almost feel like a test ID might be more robust in case this ARIA label changes. Somehow we don't wanna brittle test that we're referencing a really specific string of ARIA label. Like what if this element gets localized? And so it closed dialogue is in a different language test ID might be a little bit more robust.
So just talking through some options for what we could test, but functionally speaking, we're testing to make sure that the next thing in the tab order is something we expect. So in this case, it's a closed dialogue button. So let's add one more layer, one more interaction. So we're kind of doing this like tiered interaction in this test.
So I could also say Cy dot real press. And if I wanted to pass, say the escape key to close the dialogues and focus back where we came from, I could use that that real press escape command. I could say side up focused. So I want focus to go back to the button that triggered this dialogue so that when it opens, I've sent focus into the right spot and that when I close it, I get sent back.
So that we're really like if we hadn't done any of the accessibility work on this model, when it opened, we would've been stuck back there behind it. And then, you know, when you close it, you know, maybe this test would pass cuz our focus is still on the button. But if you open a modal layer, You really wanna send the keyboard and screen reader users into it.
So they're in the right spot. You know, they're, they're in the context that visual users are, are interacting with. And so we want to guide that interactive experience so we can write tests that really assert a lot of this stuff. So when we hit escape, our focused element should have an attribute. We'll, we'll check an ID.
So it's the same one that we checked up here with this Cy dot get command. It should have an ID value of button join basic because that's the element that we started all of this from.
Analyzing Test Results
Cypress was about to find the button, and click it to open the dialog. But once the dialog was open, it couldn’t find the “Close Dialog” aria-label.
This is an example of aria-label being a brittle thing to base a test on. We know the the button is getting focused— but the missing attribute makes the test fail.
Using a data-testid attribute might provide a good alternative for many tests. However, in this case I’ll stick with aria-label because the button’s text is only an “X” which isn’t as informative for screen readers as it could be.
Video Transcript
Sweet. So we've opened our passes page. We've still got that one, those Cypress acts issues, but let's look at what's happening with our functional test that we wrote. So we can see Cypress has found the, the button joined basic for the, the camp spots basic.
So it found the right button focus worked, cuz it's a real button and it was able to do that. And so we were able to click it and open the dialogue. So there's the dialogue. It expected div payment modal basic to have attribute role with the value of dialogue that worked. We hit the real press tab and it's not finding a button with an attribute of aria label.
Let's go see what's happening there. So if we're looking at past page I guess, you know, if I were debugging this I'd. I know where the code is, but you know, if I'm thinking through this kind of in a development workflow that I might not know right. Where to go to the code. So I might need to learn some things about this stuff on the page first.
So if I inspect this close button, I need to get it into the right state Cypress stuff. Hopefully not in the way again. So here's this button. So it is a little bit it's missing that ARIA label. So kind of talking about the pros and cons of using ARIA label to as a querying mechanism. So on the, on the downside, it was kind of brittle in that this focus ability check failed, because it didn't have the, the right ARIA label.
So we could use data test ID on the other hand, an X icon in a, a button. Isn't very descriptive as a label. So if it had an ARIA label that had the word close in it maybe that is fine thing to check against, cuz you're actually checking that it has a meaningful label, but depending on like, you know, how many languages your site is in, you know, maybe, maybe labeling is like separate, you know, that's a different test, potentially if we're checking keyboard functionality, maybe data test ID is the right one.
🛠 Solution Part 2: Running the Test and Fixing Issues
Add an ARIA Label to the Close Button
The code for the close button is inside the Dialog subcomponent inside of page-passes.js.
Adding an aria-label of “Close dialog” will take care of the first issue we have:
// inside page-passes.js
<header>
<button
aria-label="Close dialog"
className="btn-close-dialog"
...Now when we re-run the test, it fails because the Escape key doesn’t close the modal dialog so focus is still on the close button instead of back on the join button.
Implement an Escape Key Handler
On the subscription details div that surrounds the payment dialog, we’ll add an onKeyUp
handler. We’ll pass the event into a new handler function we’ll write called handleDialogEscape:
<div
aria-label="Subscription details"
onKeyUp={(event) => handleDialogEscape(event)}
...Then near the top of our component above the return, we’ll write the handleDialogEscape function.
It will take in the event, then inside we’ll add an if statement to check if event.key is the Escape key. It if is, we’ll close the dialog the same was as clicking the button would.
Here’s the the Escape key handler looks like:
const handleDialogEscape = (event) => {
if (event.key === 'Escape') {
setDialogState(false)
appContext.setInertMarkupValue(false)
}
}Saving our work and re-running the tests, everything passes as expected!
Video Transcript
so let's kind of, let's do both things. Let's go check where this closed dialogue is. Add an RA label and we'll change this today to data test ID. So within payment form, I think that that is too granular. Let's come back to our passes page. I think that's where that button is. Yeah, here it is. Button closed dialogue.
So this is within passes page. It has a dialogue that sort of gets, gets loaded into the page. When you click that button. Oh, it loads it right here in our header portal. So to get it, like the reason I put it up in the header portal was so that I could control the layers. So that there's no focusable elements behind the dialogue when it's open.
That was a lot easier to do when dialogue was a sibling to all the other content. So I'll show you that in a second. That's a focus management concern of layers.
So in this button, I'm gonna say aria label of closed dialogue. And if this was localized, you know, maybe I have a translation service or something that I invoke on this button.
Again, maybe that means I need to move labeling into a different test. And it's like screen reader, labeling and internationalization are all part of the same test. part of they're all tested in the same place, maybe. But at least we've got something more descriptive than X cuz what the heck does that mean?
If you can't see that this is a closed button, we need something more descriptive in English or whatever language that says this, the action of this button will close this dialogue. So that should make this pass now.
So, and I can hit run all again and yeah, I can see now the focus is on this. And I hit escape and it, I hit escape and it did not work . So if I'm in here, I can even test this manually. So like I come in here to this form, you know, if I hit escape, I think escape might not be implemented so we could go in it and let's go implement it.
So it should respond to the escape key and sudden focus back where we came from. So let's go look at that within page passes, we need to probably help that out. So within the dialogue, that's the context that we're in, cuz our focus is in the dialogue and we have a lot of the makings for an accessible dialogue.
But if we wanted to respond to the escape key, I think we need to go in here and add, add some logic for it. So for that, I'm going to bind an on key up handler on our dialogue. I'm gonna pass through the event object, I'll say handle dialogue, escape. Maybe I wanted to be specific cuz that's what the, there will be logic in this key handler that is going to check for the escape key.
And it's going to close the dialogue on escape. So stands to reason that our event handler should be specific. I need another curly brace to close this. So handle dialogue, escape. We've handle dialogue close, but we don't have anything doing the escape key specifically. So it's kind of like dialogue close.
That's like if I clicked the close button, but I want a specific handler for handle dialogue, escape. And I need the event object to be passed through because we are going to check if event dot key is strict equality comparison to escape. If that was, is the key that was pressed, I don't want this to close on tab.
I don't want it to close on arrow keys, only escape. That's the only one that we want right now. If that is true, I could set dialogue state to false. I guess I could potentially just call, handle dialogue close. Let's try it. Handle dialogue close, because that will do both things that will close the dialogue based on react state.
So the dialogue is like its display is hinging off of this react, use state hook. There's also app contacts being passed down through our applications. So that. This dialogue is neighboring our main landmark and our header and all those elements that contain focusable items in there. So that way, when our dialogue is opened, we can have focus only be allowed on the dialogue and all the stuff behind it.
You won't be able to reach with a screen reader or a keyboard. That's the approach that I took to make sure that our dialogue is isolated. That it's the only thing you can interact with is that I'm using inert on the background content. So we'll go, I'll show you what that looks like in the browser, but let's see if this handle dialogue close on the escape key.
Did that, did that fix our dialogue and allow it to be closed with the escape key? Yeah. Wow. It went fast. It worked . We went from not implementing that. We had a failing test, so it could go, yeah, that wasn't working yet. Now it's working so real press escape. When I kind of click on this part of the test body for that test case, I can see it has highlighted the right element.
So functionally, this is a cool way to work. I can come in and write tests for my focus management. I can make sure that they pass. Yeah, it's really cool for focus management. Of course, I still wanna test it manually as well, just make sure. But once I know everything's working, you know, I've done this little bit of validation.
That's absolutely the kind of thing I could bake into a functional test for accessibility.
The Focus Management Concern of Layers
When the modal dialog is closed, all of the regular content on the page is focusable.
If the modal is open, we don’t want the user to be stuck in the header or anywhere else behind the dialog.
To accomplish this, the dialog is rendered inside of the HeaderPortal component inside of passes-page.js. Recall that this moves it above all of the other content on the page.
When the dialog is open, the inert attribute is added to the header, main, and footer elements. This adds an aria-hidden of true and prevents the user from being able to access any of the elements behind the dialog. And just to make extra sure, these elements also have set a tabindex of -1 set which prevents them from being reached with the tab key.
Testing tab key behavior can be tricking in Cypress.
This is kind of a tricky thing to test in Cypress. Because Cypress uses an actual browser instance, if you send Tab key events after the last element in the dialog, you will end up in the browser’s toolbar.
Adding a focus trap to the page where the tab key continuously cycles through the elements might be one solution for when the dialog is open.
Video Transcript
so to kind of fill you in on what inert, this inert thing I'm talking about. So when our modal is open, I come back here. So when our modal is not open, we can navigate to all these focusable items. We have all our regular page content.
And if I open a modal don't want to be able to get stuck, you know, like all the links in the header, all those buttons, all that stuff is not something I should be able to interact with when this modal is open. And so it's kind of a difficult thing to test for though, like, Cypress dot focused, like if I am not focused in, like, if I hit tab after this last element in the dialogue, it actually sends me into the browser Chrome.
So I'm like up in the toolbar and up in, you know, dev tools or whatever. So Cypress is kind of, I, I have to kind of fine tune my approach for testing for that case. Like focus is not anything that I want. It's kind of a weird case. So this focus management for dialogues. I think potentially could be a good Cypress component testing use case for focus management.
So that way you can do, like we did with our, we were testing, I guess that was in, might have been in Jest actually, but in, in a different exercise we've seen this radio button pattern where I injected a button in the test case that gave me something to check that focus had moved to. You know, maybe there's an approach like that.
The problem, I mean, I guess one other thing we could consider is adding a focus trap, so that focus would just loop. That would be actually easier to test for automated. Whereas right now I'm just letting it go to the browser stuff outside. It's like the modal is the only thing that's focusable so it's accessible.
But sometimes the quirks of automation you're like, wait, it's throwing an error because nothing is focused. Thinking out loud here, but talking through inert, what makes this possible to not have all the items focused in the background? So portal root is where our dialogue content is and our other content like our header, our main that's behind the dialogue.
They have the inert attribute and inert is awesome because it adds aria hidden of true for us. So it removes this content from the accessibility tree. So it's not stuff that screen readers are going to interact with. It also goes one step further. And goes finds any interactive items within the, those parts of the page.
And it will add tab index of negative one when inert is available. So inert is awesome, like using that with a focus trap for layers, like this means that you just have bullet proof layers, that users will never get stuck back there because there's nothing to focus on. And so for modals and dialogues and stuff, that's functionally something that you should consider

